Write tests for your FastAPI endpoints using TestClient — no running server needed.
Day 70 of 80
FastAPI provides a TestClient that simulates HTTP requests without actually starting a server. You import your app and make requests against it directly in your test code.
This is fast, reliable, and self-contained. No ports, no network, no timing issues — just your app code responding to test requests.
Install the test dependency first:
pip install httpx
Then create tests/test_api.py:
# tests/test_api.py — tests for the FastAPI endpoints.
# FastAPI's TestClient simulates HTTP requests without a real server.
# Run with: pytest tests/test_api.py -v
import pytest
from fastapi.testclient import TestClient
from api import app, db
# The TestClient wraps your app and lets you make requests.
# It handles startup and shutdown events automatically.
client = TestClient(app)
# --- GET /prompts ---
def test_list_prompts_returns_list():
"""GET /prompts should return a JSON array."""
response = client.get("/prompts")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_list_prompts_with_platform_filter():
"""GET /prompts?platform=Kling should only return Kling prompts."""
# First add a known prompt so we have something to filter on
data = {"platform": "Kling", "shot": "Filter test shot", "prompt_text": "Filter test prompt"}
client.post("/prompts", json=data)
response = client.get("/prompts", params={"platform": "Kling"})
assert response.status_code == 200
results = response.json()
# Every result should be a Kling prompt
for r in results:
assert r["platform"] == "Kling"
# --- POST /prompts ---
def test_create_prompt_success():
"""POST /prompts with valid data should return 201 and the created prompt."""
data = {
"platform": "Runway",
"shot": "Test shot from pytest",
"prompt_text": "Cinematic test prompt for API test"
}
response = client.post("/prompts", json=data)
assert response.status_code == 201
result = response.json()
assert result["platform"] == "Runway"
assert result["shot"] == "Test shot from pytest"
assert "id" in result # database assigned an ID
assert "created_at" in result
def test_create_prompt_invalid_platform():
"""POST /prompts with unrecognized platform should return 422."""
data = {
"platform": "Midjourney", # not a valid platform
"shot": "test",
"prompt_text": "test"
}
response = client.post("/prompts", json=data)
# 422 = Unprocessable Entity — Pydantic validation failed
assert response.status_code == 422
def test_create_prompt_empty_shot():
"""POST /prompts with empty shot should return 422."""
data = {
"platform": "Kling",
"shot": " ", # whitespace only
"prompt_text": "test"
}
response = client.post("/prompts", json=data)
assert response.status_code == 422
# --- GET /prompts/{id} ---
def test_get_prompt_by_id():
"""GET /prompts/{id} should return the prompt if it exists."""
# Create one first
data = {"platform": "Veo", "shot": "Get-by-id test", "prompt_text": "Get-by-id prompt"}
create = client.post("/prompts", json=data)
prompt_id = create.json()["id"]
# Now fetch it by ID
response = client.get(f"/prompts/{prompt_id}")
assert response.status_code == 200
assert response.json()["id"] == prompt_id
assert response.json()["platform"] == "Veo"
def test_get_prompt_not_found():
"""GET /prompts/99999 for a non-existent ID should return 404."""
response = client.get("/prompts/99999")
assert response.status_code == 404
# --- DELETE /prompts/{id} ---
def test_delete_prompt():
"""DELETE /prompts/{id} should remove the prompt and return confirmation."""
# Create one to delete
data = {"platform": "Kling", "shot": "Delete me", "prompt_text": "To be deleted"}
create = client.post("/prompts", json=data)
prompt_id = create.json()["id"]
# Delete it
response = client.delete(f"/prompts/{prompt_id}")
assert response.status_code == 200
# Verify it's gone
get_response = client.get(f"/prompts/{prompt_id}")
assert get_response.status_code == 404
def test_delete_nonexistent_prompt():
"""DELETE /prompts/99999 should return 404."""
response = client.delete("/prompts/99999")
assert response.status_code == 404
# --- GET /stats ---
def test_get_stats():
"""GET /stats should return a dictionary mapping platform names to counts."""
response = client.get("/stats")
assert response.status_code == 200
stats = response.json()
assert isinstance(stats, dict)
# All values should be integers
for platform, count in stats.items():
assert isinstance(count, int)
assert count > 0
TestClient makes real HTTP-style requests to your app without needing a running server. It's synchronous — you can call it in regular test functions without async def or await.
The create-then-fetch pattern (test_get_prompt_by_id) is the right way to test retrieval. Don't assume any particular data exists in the database — create what you need, then test with it. Tests should be self-contained.
The create-then-delete pattern (test_delete_prompt) verifies the whole lifecycle: create something, delete it, then confirm it's gone. Three HTTP calls, one assertion chain.
422 vs 404: 422 means the request itself was invalid (bad data format). 404 means the request was valid but the resource wasn't found. These are different situations and should return different codes.
pytest tests/ -v
You'll see the model tests from Days 68–69 and the API tests from today all pass together. That's your full test suite.
You now have a test suite that covers both the data model and the API. Every time you change the code, run pytest and know in seconds if anything broke.
Professional Python projects run tests automatically on every commit. You now understand why — and how.
tests/test_api.py created with all endpoint testspytest tests/ -v